iT邦幫忙

2026 iThome 鐵人賽

0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 57 篇

Day 32 特別篇(中)|Langfuse vs. LangSmith:AI Observability 要怎麼選、怎麼整合?

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說:選型會議上的直覺很難被說服,但一份 side-by-side 跑出來的 trace 可以——這篇(中)先把中立的 AI telemetry schema、OpenTelemetry 接線與遮罩規則建起來,讓 Langfuse 與 LangSmith 看到同一份可信任的資料。

承接上文:上一篇(上)先劃清 Infrastructure Reliability 與 AI Workflow Reliability 的責任邊界,說明 Langfuse 與 LangSmith 的共同能力與工程條件差異(尤其是 OTel 相容与 gen_ai.* schema 仍在演進這兩件事),再列出選型該問的六個問題,並分別整理出「什麼情況選 LangSmith」「什麼情況選 Langfuse」。這一篇接著動手,把上一篇談的原則變成真正可以跑的程式碼。

⑦ 今日 DIY:相同 workflow 的 side-by-side Lab

以下步驟由讀者自行在 local 或 staging 環境執行。本文不建立 DIY/、不下載 image、不安裝套件、不啟動 compose,也不會宣稱 Lab 已驗證。

在動手之前,先說清楚這一節 DIY 想驗證的到底是什麼,否則很容易變成「照著抄一堆程式碼」而不知道每一段在回答哪個問題。這 22 個 Step 不是隨機排列的教學清單,而是按照「先講中立資料模型 → 再談單一平台的最小可行接線 → 最後才是雙平台比較與治理決策」的順序展開,剛好對應本文①到⑥每一段留下的懸念:

①②③ 講的是「責任邊界」與「選型該問什麼問題」
                    │
                    ▼
Step 1–6    把「中立 schema」與「一條乾淨的 OTel 輸出路徑」做出來
                    │      ← 驗證:資料模型不綁死任何平台,OTel context 正確傳遞
                    ▼
Step 7–11   在同一個 workflow 上,同時觀察兩個平台會看到什麼
                    │      ← 驗證:trace 可比較、敏感資料已被攔在系統邊界內
                    ▼
Step 12–16  把「Lab 用的 direct exporter」升級成「production 該有的樣子」
                    │      ← 驗證:Collector 隔離、span tree 切法、技術成功 vs 語意失敗
                    ▼
Step 17–22  把觀察結果變成一份可以被反駁、可以被追蹤的決策紀錄
                    │      ← 驗證:選型不是感覺題,是有 owner、有退場條件的工程決策
                    ▼
④⑤⑥        回頭驗證「什麼情況選誰」「能不能雙寫」是否站得住腳

換句話說,這一整節 DIY 的存在意義,不是教你把 Langfuse 或 LangSmith 接起來(那件事官方文件本身寫得比這篇文章更權威、更即時),而是示範一套「面對任何 AI observability 平台,都應該先問這些問題、先做這些驗證」的方法論。即使你最後選的平台和本文範例完全不同,這套順序依然適用。

案例情境:一次真實會發生的事故,會怎麼被這 22 步接住

在照著 Step 1 開始動手之前,先用一個虛構但貼近現實的情境,把整套 Lab「為什麼要這樣設計」串起來,讀者會比較容易在讀 22 個 Step 時知道自己在驗證什麼,而不是照抄程式碼。

情境設定:一個內部「政策問答」服務(跟 Day 19 用過的同一種 RAG workflow)已經上線三個月,架構是 FastAPI + retrieval + Gemini + 一個 validator,觀測層目前只接了 LangSmith(因為當初是用 LangChain 起家,SDK-native 接得最快)。某天週五下午,值班的 SRE 收到一則告警:quality_status=failed 的比例在 20 分鐘內從 2% 跳到 34%,但 HTTP 層的 error rate 完全正常,P95 延遲也沒有異常——這正是 ①③ 反覆強調的「技術成功、語意失敗」的教科書案例。

如果這個服務只做到 Step 1–6(中立 schema +單一平台 OTel 接線),值班工程師這時候能看到的東西相當有限:LangSmith 上能查到 trace,能看到哪些 request 被 validator 標記為 failed,但無法立刻回答「這是不是同一批 prompt version 造成的」「retrieval index 是不是剛好在事故發生前十分鐘被重建過」——因為 Step 1 定義的 workflow_version、prompt_version、retrieval_index_version 欄位,如果沒有被實際串進每一筆 trace 的 attribute,光有 schema 定義沒有意義。這正是 Step 1 那段「驗證的是 schema 設計站不站得住腳」在講的事:跑得起來不等於有用,欄位要能真正回答值班當下最急迫的問題。

如果值班的 SRE 追下去,會發現觸發點是:一次 retrieval index 的例行重建(每天凌晨自動跑的 cron job),這次因為上游文件來源多了一批新政策文件,新文件裡的措辭跟 validator 的判斷規則有些微衝突,導致 validator 對一部分本來合法的回答判定為 failed。這個 root cause 鏈——「index 版本 → retrieval 結果變化 → 下游 validator 誤判」——如果 Step 15 講的 span tree(ai.workflow → retrieval → validator 三層父子關係)有被確實建立,而且 retrieval_index_version 有被寫進 retrieval span 的 attribute,值班工程師應該能在 5 分鐘內、不用讀一行程式碼,就從 trace 樹上看出「這批 failed 的 request,retrieval span 的 index version 都指向同一個新版本」,進而回頭去查那次 cron job 的變更內容。這就是 Step 9 與 Step 16 想驗證的「有沒有能力同時比較技術成功與語意失敗」。

再往下一層想:如果這個服務除了 LangSmith 之外,也同時用 Step 3–7 講的 OTel-native 方式把同一批 trace 送進一份自架的 Langfuse(或任何支援 OTLP 的後端),值班工程師手上會多一個交叉驗證的工具——不是因為 Langfuse 比 LangSmith「更準」,而是因為兩個平台各自的 trace 檢視器、篩選器、UI 呈現方式不同,同一批資料換一個角度看,常常能更快發現規律(例如 Langfuse 的 Trace/Observation 分層方式,剛好比較適合按 retrieval_index_version 分組篩選)。這正是 ④ 段落「side-by-side 不是為了選贏家,而是為了驗證同一份資料在不同呈現方式下,能不能撐住同一個決策」的具體展開。

最後,如果這次事故真的發生過,Step 17–22 要求的「決策紀錄」該長什麼樣子?不是一句「我們決定用 Langfuse」,而是一份能回答「這次事故之後,我們對 retrieval index 版本的追蹤精細度,有沒有因為選型而改變」的 ADR。這也是為什麼 Step 22 特別強調「為選型結果留下可變更的紀錄」——選型結論不是一次性的答案,而是要能隨著像這樣的真實事故不斷被檢驗、被更新的活文件。

帶著這個情境往下讀 Step 1 到 Step 22,會發現每一步都在為「值班工程師能不能在 5 分鐘內從技術成功查到語意失敗的 root cause」這件事鋪路——這也是整節 DIY 唯一真正重要的驗收標準,比「兩個平台哪個比較好用」重要得多。

這個情境還藏著一個容易被忽略的變數,值得提前埋在這裡,後面讀到⑥段落的取樣數學時再回頭對照:如果這個服務當初為了省成本,把 trace 的取樣率設定在 10% 左右,值班工程師還能不能在 5 分鐘內看出「這批 failed 的 request,retrieval span 的 index version 都指向同一個新版本」?答案很可能是不行——因為 34% 的失敗率、20 分鐘的觀察窗,換算下來這批失敗的 request 數量可能只有幾十筆,10% 的取樣率意味著能被保留下來、可供比對的 trace 可能只剩個位數,樣本小到不足以看出「都指向同一個 index version」這種需要多筆資料交叉比對才能發現的規律。這正是為什麼本文後面會特別強調「失敗事件一律 100% 保留,只有成功案例才取樣」——如果反過來,把取樣規則套用在所有流量(包括失敗案例)上,事故當下最需要的那批證據,很可能在事故發生的當下就已經被骰子刷掉了。

Step 1:先定義中立 AI telemetry schema

建立 app/telemetry/ai_context.py。先定義自己的資料模型,再映射到 LangSmith、Langfuse、OTel、logs 與 metrics;不要讓任一工具的欄位決定你的服務該記錄什麼。

from dataclasses import dataclass

@dataclass
class AITraceContext:
    request_id: str
    trace_id: str
    task_id: str
    workflow_name: str
    workflow_version: str
    prompt_version: str
    model_provider: str
    model_name: str
    retrieval_index_version: str
    service_version: str
    deployment_id: str

再為每筆 request 加上 technical_status、quality_status、safety_status、task_status、tool_policy_decision 與 degraded。不把 credential、access token、完整 prompt、完整文件或高基數 ID 放進 Prometheus label。

為什麼先寫這個 dataclass,而不是直接打開 Langfuse 或 LangSmith 的 SDK 文件抄範例? 這是整節 DIY 刻意的第一步,也是最容易被跳過的一步。如果第一行程式碼就是 from langfuse import Langfuse,接下來每一個欄位名稱、每一種資料型別都會被那個 SDK 的資料模型牽著走——之後想比較另一個平台,甚至只是想換一種欄位命名慣例,都得回頭改動業務邏輯裡已經散落各處的呼叫。先寫一個跟任何平台都無關的 AITraceContext,再各自寫一層轉接(映射到 Langfuse attribute、LangSmith metadata、Prometheus label、structured log),才能確保「平台換了,業務程式碼不用跟著換」——這正是 ② 段落裡討論 OTel-native 為何比 SDK-native 更耐用的同一個道理,只是這次落在資料模型上,而不是 span API 上。

這個實作驗證了什麼? 它驗證的是「中立 schema 設計本身站不站得住腳」,而不是任何一個平台的連線是否成功。跑起來後你應該能檢查:這個 dataclass 的每個欄位,是不是都能同時回答「這是哪一版 prompt」「這是哪一版 retrieval index」「這次 request 算不算成功」三類問題,而完全不需要引用任何平台專屬的詞彙。如果答不出來,代表 schema 設計還在半路上,先別急著往下接 Langfuse 或 LangSmith。

預期會踩到的坑: 最常見的失誤是把「高基數欄位」也塞進這個 dataclass,例如把完整的使用者 ID、完整的 session token 也當成 trace context 的一部分。這在 Lab 階段看起來沒問題(反正只有你自己在測),一旦流量變大、或這份 context 被拿去當 Prometheus label 的來源,就會直接複製 Day 02 提過的「高基數 label 拖垮 metrics backend」的坑。解法很單純:AITraceContext 只放低基數、可版本化的欄位;真的需要追蹤到單一使用者,用 request_id 或 trace_id 反查 log,而不是把使用者資訊直接攤開在 trace context 裡。

Step 2:建立 Langfuse local/staging 設計,不把它誤當 production

新增 docker-compose.langfuse.yml 前,先從官方 deployment guide 取得與目標版本一致的範例,不要複製過期 blog 的 image tag 或預設密碼。Lab 設計至少要列出:

langfuse web / ingestion API
langfuse worker
Postgres
ClickHouse
Redis 或 Valkey
S3-compatible object storage

在啟動前檢查設定,而不是直接把它帶到 production:

docker compose -f docker-compose.langfuse.yml config

預期結果是 compose config 能解析、secret 不在版本控制中、資料 volume 與 Lab-only boundary 已被寫下。這不代表 stack 的 HA、backup、retention 或安全控制已經驗證。

這一步在驗證文章前面哪個論點? 它直接對應 ③ 與 ⑤ 反覆強調的「自架的資料控制權和維運負擔是同一個選擇的兩面」。docker compose config 這個指令本身很不起眼,但它強迫你在動手之前,先把 web/worker/Postgres/ClickHouse/Redis/object storage 這六個元件都列出來、看清楚——而不是含糊地說一句「我們有 docker compose,可以自己架」。GitLab 2017 年的事故裡,沒有人真正「擁有」備份流程,才會讓四層備援同時悄悄失效;這裡先把每個元件列出來,是在事故發生的好幾個月前,就先問「這個元件的 owner 是誰」的第一個動作。

預期會踩到的坑: 讀者常見的反應是「先跑起來再說,設定之後再補」,但這一步刻意只停在 config(解析設定,不真的啟動容器),就是要在真的把 stateful 服務跑起來、產生真實資料之前,先把 secret 管理和 volume 邊界想清楚。如果這時候發現 secret 被寫死在 compose 檔案裡、或分不清哪個 volume 是 Lab-only、哪個未來可能被誤用成 production 資料——代表這一步的目的已經達成:它讓你在花時間跑 docker compose up 之前,先發現這些問題,而不是等到已經累積了幾週的 trace 資料後才發現。

Step 3:OpenTelemetry × Langfuse,先選一條輸出路徑

Langfuse Python SDK 的 nested observations 使用 OpenTelemetry context propagation。若你的 service 已有 OTel pipeline,也可以用 generic OTLP exporter 將 spans 送到 Langfuse。兩種方法都合理;同一個 process 對同一批 span 選一種 primary export path,否則同一 request 可能出現兩份 trace。

以下是讀者可在 Lab 使用的 OTLP 設定。${LANGFUSE_BASE_URL} 必須指向你的 cloud region 或 self-hosted Langfuse host;不要把 public/secret key 寫進程式或 commit。

export OTEL_EXPORTER_OTLP_ENDPOINT="${LANGFUSE_BASE_URL}/api/public/otel"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic ${LANGFUSE_OTLP_AUTH},x-langfuse-ingestion-version=4"

LANGFUSE_OTLP_AUTH 是 public_key:secret_key 的 Base64 結果,只能由 local secret store 或部署平台注入。版本 header 需以目前官方文件為準;不要把本文範例當成永久設定。

若要以程式建立 dedicated exporter,可先用最小 Lab pipeline。這段不會自動追蹤 FastAPI、LLM 或 retrieval;它只把你建立的 OTel spans 送到 Langfuse。

import base64
import os

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor

auth = base64.b64encode(
    f"{os.environ['LANGFUSE_PUBLIC_KEY']}:{os.environ['LANGFUSE_SECRET_KEY']}".encode()
).decode()

provider = TracerProvider(
    resource=Resource.create({"service.name": "answer-api"})
)
provider.add_span_processor(
    SimpleSpanProcessor(
        OTLPSpanExporter(
            endpoint=f"{os.environ['LANGFUSE_BASE_URL']}/api/public/otel/v1/traces",
            headers={
                "Authorization": f"Basic {auth}",
                "x-langfuse-ingestion-version": "4",
            },
        )
    )
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("answer-api")

這是教學用的 direct exporter,只適合 local Lab。staging/production 較穩妥的路徑是 Python application → OTLP → OpenTelemetry Collector → Langfuse:由 Collector 處理 batching、retry 與 routing,避免 Langfuse ingestion 的暫時問題直接耦合到 request path。若 production service 已有 shared TracerProvider、Collector 或 exporter policy,應由既有 OTel architecture 統一設定,不要在各 handler 私自建立第二個 provider。

這一步在驗證什麼? 它驗證的是②段落那句「OTel-native 讓平台只是 exporter 的目的地」是不是真的成立——而不是只驗證「有沒有把 span 送到 Langfuse」。判斷方式很直接:把上面這段 OTLPSpanExporter 換成另一個廠牌的 OTLP endpoint(例如換一個自架的 Tempo 或任何相容 OTLP 的後端),只改 endpoint 跟 headers 兩行,其他程式碼完全不動——如果真的只需要改這兩行,就代表這個 Lab 的資料模型跟業務邏輯確實沒有跟 Langfuse 綁死;如果發現除了這兩行還得改動 retrieve()、call_model() 這些業務函式,代表某個地方悄悄依賴了 Langfuse 專屬的 API,回頭檢查是不是不小心把 SDK-native 的呼叫方式(trace.observation 這類 decorator)也一起加進去了。

預期會踩到的坑: 第一個常見錯誤是把 LANGFUSE_OTLP_AUTH 的 Base64 編碼算錯——public_key:secret_key 中間那個冒號很容易被忘記,一旦漏掉,Langfuse ingestion API 會回傳認證錯誤,但錯誤訊息不一定明顯指出是編碼問題,容易被誤判成 endpoint 打錯。第二個常見錯誤是在同一個 process 裡同時初始化了兩個 TracerProvider(例如某個第三方套件自己也呼叫了 trace.set_tracer_provider),第二次呼叫會被 OpenTelemetry SDK 靜默忽略,導致你以為設定生效了,實際上 span 還是送到第一個 provider 對應的目的地——這也是為什麼上面特別強調「不要在各 handler 私自建立第二個 provider」,這不是風格建議,而是真的會讓資料送錯地方。

Step 4:讓 FastAPI request 成為 workflow 的 parent span

使用既有 FastAPI OTel instrumentation 時,server request span 會是 parent;在 handler 內以 current context 建立 ai.workflow、retrieval、generation、tool、validator child span。FastAPI 與 downstream HTTP instrumentation 應自動傳遞 W3C Trace Context;不要自行拼接 trace ID。若還沒導入 FastAPI instrumentation,也可先用手動 root span 觀察 schema,但它不能取代完整 HTTP telemetry。

from opentelemetry import trace

tracer = trace.get_tracer("answer-api")

def answer_workflow(question: str, request_id: str) -> dict:
    with tracer.start_as_current_span("ai.workflow") as workflow:
        workflow.set_attribute("langfuse.trace.name", "answer-workflow")
        workflow.set_attribute("langfuse.trace.metadata.request_id", request_id)
        workflow.set_attribute("langfuse.trace.metadata.workflow_version", "v1")

        with tracer.start_as_current_span("retrieval") as retrieval:
            documents = retrieve(question)
            retrieval.set_attribute("retrieval.result_count", len(documents))

        with tracer.start_as_current_span("validator") as validator:
            answer = call_model(question, documents)
            result = validate(answer, documents)
            validator.set_attribute("ai.quality_status", result.status)

        return {"answer": answer, "quality_status": result.status}

request_id 保留在 trace metadata、structured log 與 response correlation;它是業務 correlation,不取代 OTel trace context,也不應加入 Prometheus label。retrieval.result_count、版本與狀態適合用 attribute;完整 question、完整 document、API key、token、email 或 access control data 預設不應寫入。

這段程式碼在驗證什麼、又容易在哪裡踩雷? 這是整個 DIY 裡第一次把 Step 1 的 schema 真正落地成一棵 span tree,驗證的正是①段落最後戳破的那個誤解——「有記錄 trace 不等於有 observability」。單看程式碼,ai.workflow 底下巢狀著 retrieval 和 validator,看起來只是縮排問題;但這個巢狀結構決定了未來能不能回答「這次失敗,是 retrieval 慢還是 validator 判斷錯」。如果把三個步驟攤平成同一層級的三個獨立 span,看起來省事,實際上等於把因果關係丟掉,回到本文 ①一開始批評的那種「有 log 沒有 trace」的狀態。

跑這段程式碼時最常見的坑,是誤以為 workflow.set_attribute("langfuse.trace.metadata.request_id", request_id) 這樣加了字首就等於做了 masking——事實上它只是把值放進一個特定命名空間的 attribute,內容本身完全沒有被過濾。真正的敏感資料控管要靠 Step 5 的 masking 設定或 Step 11 的 allowlist,這裡先寫出這段程式碼,只是為了讓你在加上任何遮罩機制之前,先清楚看到「什麼東西正準備被送出去」。

這不是新問題:Uber 也曾在同一個坑摔過一次大的

tracer.start_as_current_span() 之所以能在巢狀的 with 區塊裡自動維持正確的 parent-child 關係,靠的是 Python 的 contextvars——一種「每個並行執行單位各自擁有一份獨立複本」的 context 儲存機制。這件事聽起來像實作細節,但它解決的正是一個業界已經真實摔過跤的坑。

Uber 在 2015 年秋天大約有 500 個微服務,內部原本的 tracing 系統「完全沒有 distributed context propagation 的概念」,最多只能往下追一層呼叫;到了 2017 年初,服務數已成長到超過 2,000 個(Evolving Distributed Tracing at Uber Engineering)。當時團隊嘗試改用非同步的 Tornado framework 重寫 tracing prototype,卻踩到一個更根本的坑:他們沿用了舊有「把 trace context 存進 thread-local 變數」的作法——也就是把 context 當成「這個 thread 目前唯一在跑的東西」來存取。但 Tornado 的 IOLoop 讓同一個 OS thread 可以同時交錯執行多個彼此無關的 request;thread-local 儲存的假設一旦遇上這種並行模型,就會被不同 request 互相覆寫、串錯 context——A 請求的 span 樹,可能被硬串進 B 請求的父節點底下。這正是後來 Uber 全面改採 explicit context propagation、進而催生 Jaeger 的直接原因。

Thread-local context 的假設               Async IOLoop/contextvars 的現實
「一個 thread,同一時間          ≠         「一個 thread,同一時間
  只服務一個 request」                       可能交錯著跑多個 request 的程式碼」
        │                                          │
        ▼                                          ▼
  context 存進 thread 的                    每個並行執行單位(coroutine/task)
  全域變數,安全無虞                          需要各自獨立的 context 複本,
                                              否則 context 會互相污染

FastAPI 本身就是 async framework,跑在跟 Uber 當年的 Tornado 同一種並行模型上——同一個 thread、同一個 event loop,交錯執行著多個 request 的 coroutine。這也是為什麼 Step 4 這段程式碼特別依賴 OpenTelemetry SDK 內建的 contextvars 支援,而不是自己用一個全域變數手動存取「目前的 span」:如果哪天團隊為了效能考量,手寫了一個看似更輕量的 context 儲存機制(例如直接用一個模組層級的變數存「current_span」),很可能在不知不覺間,重演一次 Uber 花了好幾個月才排除的同一種 bug——差別只在於 Uber 是在兩千個微服務的規模上發現,你的團隊可能是在一次 production incident 現場,看到兩個完全不相關使用者的 trace 詭異地被串在同一棵 span 樹下時才發現。

把這個對比寫成程式碼會更清楚問題出在哪裡。以下是刻意示範的錯誤寫法,看起來省事,卻正好重現了 Uber 當年 thread-local 的坑:

# 錯誤示範:用模組層級全域變數手動存「目前的 span」
_current_span = None  # 危險:這是整個 process 共用的一個變數


def bad_start_span(name: str):
    global _current_span
    _current_span = create_span(name)  # 多個並行 request 會互相覆寫這個變數
    return _current_span


# 正確做法:交給 OpenTelemetry 的 contextvars-based context 管理
with tracer.start_as_current_span("ai.workflow") as workflow:
    ...  # 每個 coroutine 各自持有自己的 context 複本,彼此不互相干擾

_current_span 這個全域變數的問題,跟 Uber 的 thread-local 變數本質上是同一件事:只要有兩個並行執行單位(不論是 thread 還是 coroutine)共用同一個儲存位置,後寫入的一方就會覆寫先寫入的一方。contextvars 之所以能解決這個問題,是因為它專門為「同一個 thread 上有多個並行任務」這種情境設計——每個 task 進入自己的 context 時,會拿到一份獨立複本,而不是共用同一塊記憶體。這也是為什麼本文從頭到尾都建議直接使用 tracer.start_as_current_span(),而不是自己手刻任何形式的「全域目前狀態」。

Step 5:遮罩、propagation 與正常結束

若選 Langfuse Python SDK mode,可用 propagate_attributes 在 nested observations 帶入 user、session、metadata、tags、release 或 environment。使用 SDK 的 OTel-exported spans 時,可配置 mask_otel_spans 做 export-stage redact;generic OTLP mode 則要在 set_attribute() 前自行清理敏感值,因為它不會自動套用 SDK masking callback。

request 結束、測試結束或 worker shutdown 時,要依使用的 client/provider 執行對應的 flush()/shutdown();否則短生命週期程序可能在 exporter 送出前結束。不要在 request handler 裡每次 shutdown provider。

這一步為何要獨立拉出來,不直接併進 Step 4? 因為遮罩和 flush 是兩種完全不同性質的失敗:遮罩沒做好,是資料外洩事故的種子,正如三星 ChatGPT 事故所示範的——問題往往不是被誰惡意利用,而是根本沒有人在資料離開邊界前設下攔截點;flush() 沒做好,則是一種安靜到你完全不會發現的資料遺失——短命令列程式或 Lab script 執行完就退出,exporter 的 buffer 裡還有沒送出的 span,程式一結束,那些 span 就永遠消失,而且不會有任何錯誤訊息。跑一次 fixture 之後,如果 Langfuse 或 LangSmith 的 UI 上什麼都沒出現,先檢查的不是網路設定,而是有沒有漏掉 shutdown 這一步——這是 Lab 階段最常見、也最容易被誤判成「連線失敗」的坑。

Step 6:驗證同一條 trace,而不是只看 UI

在隔離環境送出一筆正常 fixture 和一筆 semantic-failure fixture,檢查:

  1. FastAPI request span 與 ai.workflow 是否有正確 parent-child 關係。
  2. retrieval、validator 是否是 workflow 的 child span。
  3. request_id、workflow version、quality status 是否可在 Langfuse 查到。
  4. 敏感 email、API key 與完整 prompt 的 negative fixture 是否沒有出現在已送出的 attribute。
  5. downstream HTTP call 是否保留相同 W3C trace context。
  6. request_id 能否從 Langfuse trace 回到你原本的 log 與 OTel backend。

這是讀者的 Lab 驗收,不是本文的執行結果。

這六項檢查刻意混用了兩種驗收方式:前三項是「trace 有沒有正確建立」,後三項是「trace 有沒有正確地不外洩」。很多團隊只做前半段——因為 UI 上看得到樹狀結構,容易有「做完了」的錯覺——後半段的 negative fixture 卻經常被跳過,因為它不會直接讓 demo 好看,也不會讓任何 dashboard 變綠。但正是這後三項,才是真正回答③段落那句「trace 是否含敏感 prompt、內部文件或個資」的地方;沒做過負向驗證的遮罩設定,充其量只是一段令人安心的程式碼,沒有人知道它實際上有沒有作用。

為什麼「查得到一個 trace_id 字串」不等於「連得到真正的 trace」

第六項檢查——「request_id 能否從 Langfuse trace 回到你原本的 log 與 OTel backend」——看起來像是最不需要驗證的一項,畢竟 request_id 就印在 log 裡,肉眼可見。但工程實務上,log 裡印出來的 trace 識別碼「看起來合法」跟「真的連得回那條 trace」,是兩件經常被混為一談的事:一份 2026 年的工程分析把這個問題拆成四個各自獨立、卻常常一起發作的失效環節(Why Trace IDs in Logs Fail to Link: Formats, Sampling, and Data Sources):

Log 裡的 trace_id 連不回 trace 的四個獨立失效點
├─ 格式錯誤:16 碼的 span ID 誤放進 trace 欄位、多了 0x 前綴或大小寫不一致
│              (規格要求 trace ID 固定 32 碼小寫十六進位、span ID 16 碼)
├─ Context 沒被正確捕捉:pooled thread/async executor 邊界上,
│              log 讀到的其實是別的 request 洩漏過來的 context
├─ 取樣或匯出失敗:這條 trace 一開始就沒被抽中保留,
│              或 exporter queue 滿了、後端拒收、retention 已過期
└─ Tenant/資料來源/時間不一致:連結指向了 production 的後端,
               但這筆資料其實在 staging;或兩端時鐘沒對齊,UI 查詢的時間窗太窄

這張圖值得對照本文 Step 5 已經強調過的「flush 沒做好是安靜的資料遺失」——這裡是同一種安靜失效的另一個入口,只是它藏在「格式看起來對」這層更容易被誤信的偽裝底下。第一項(格式錯誤)最容易在 Lab 階段被忽略,因為手動測試時很少故意去檢查 ID 的長度與字元集;下面這段小工具,可以在送出前先做一次基本檢查,避免把明顯不合規格的字串寫進 log:

import re

_TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_SPAN_ID_RE = re.compile(r"^[0-9a-f]{16}$")


def assert_valid_w3c_ids(trace_id: str, span_id: str) -> None:
    if not _TRACE_ID_RE.match(trace_id):
        raise ValueError(f"trace_id 格式不符 W3C 規格(應為 32 碼小寫十六進位):{trace_id!r}")
    if not _SPAN_ID_RE.match(span_id):
        raise ValueError(f"span_id 格式不符 W3C 規格(應為 16 碼小寫十六進位):{span_id!r}")
Trace 存在   ≠   Trace 可信
(log 裡印著一串合法格式的 ID)    (這串 ID 真的能在平台上查到對應資料,
                                    而且指向的是正確的環境與時間窗)

第二、三、四項則提醒一件事:光是「格式對了」,也只解決了四分之一的問題。Step 4 討論的「Header 在跨越中介層時被剝除」是第二項的一種具體樣貌;本文⑥段落剛補上的取樣數學則是第三項的量化版本——一條 trace 從一開始就沒被抽中保留,跟它被抽中了卻在 exporter queue 塞滿時遺失,對值班工程師而言是同一種體感:log 裡有個 ID,平台上什麼都查不到。第四項最容易在雙平台比較時中招:如果 Lab 環境和 production 環境共用同一套 log 格式,卻各自指向不同的 Langfuse project 或不同的 LangSmith workspace,值班工程師很可能對著錯誤的資料來源查了老半天,才發現連結指向的是三天前的 staging 資料。第六項驗收之所以放在最後,正是因為它把前面五項檢查串起來的最後一哩路,也是最常被誤以為「應該沒問題」而跳過的一項。

Step 7:用同一組案例產生 AI trace

沿用同一條 /ask:

POST /ask → Prompt Builder → Retriever → Mock/External LLM
          → Tool Policy Engine → Parser → Validator → Response

準備兩筆 fixture:

  1. 正常案例:retrieval、parser、source validation 與 task 都完成。
  2. semantic failure:HTTP 200,但模型漏掉或答錯必要事實,quality evaluator 判定失敗。

在 Lab 中讓兩個平台看到同樣的 request_id、workflow version、prompt version、retrieval index version 與 outcome。比較 root trace、child observation、版本、retrieval 結果、latency、quality status,以及如何將 trace 放進 dataset。敏感資料先遮罩。

這裡驗證的核心論點是「同一份輸入,兩個平台看到的世界應該一致」。 這句話聽起來理所當然,實際操作起來卻很容易翻車:如果 Langfuse 那邊的 masking 規則和 LangSmith 那邊的 redaction 設定不是同步維護,兩個平台顯示的 trace 內容就會悄悄分岔——這正是⑥段落最後強調「雙寫入必須先假設遮罩規則一致」的來源。第二筆 semantic-failure fixture 尤其重要:如果只跑正常案例,你永遠不知道平台的 evaluator 能不能真的抓出「HTTP 200 但答案是錯的」這種本文開頭就指出的核心風險;跑過一次故意設計成會失敗的案例,才算是真正驗證了①段落畫的那張「語意層失敗不會反映在技術層訊號」的圖。

Step 8:以同一份 dataset 比較 evaluation workflow

沿用 policy_task_dataset_v1,至少列出:

- Source validation
- Expected fact check
- Task success classification
- Safety refusal test
- Prompt v1 vs Prompt v2 comparison

產出 LangSmith vs Langfuse Lab Report,記錄 trace usability、metadata filtering、dataset workflow、evaluation workflow、prompt comparison、OTel compatibility、資料治理控制與 operational overhead。這份 report 的目的不是宣布贏家,而是讓團隊能依約束選出 primary platform。

為什麼要用「同一份」dataset,而不是各自在兩個平台上分別建一份看起來差不多的測試案例? 因為 evaluation workflow 真正要驗證的,不是「這個平台的 evaluator 功能好不好用」,而是「面對完全相同的輸入與期望輸出,兩個平台的判定結果會不會不一樣」。如果 dataset 不是同一份,任何判定差異都可能只是因為兩邊的案例本來就不完全一樣,根本無法拿來做選型依據。這也是為什麼要求列出 Prompt v1 vs Prompt v2 comparison 這種明確的 A/B 對照項目——選型決策最後往往要回答「換了 prompt 版本之後,兩個平台能不能同樣清楚地告訴我品質有沒有變好」,而不是只看單一版本跑起來順不順。

預期會踩到的坑: 最常見的落差出現在「兩個平台對『通過』的定義不完全一致」——同一筆 fixture,Langfuse 的 evaluator 判定通過的門檻,跟 LangSmith 的 evaluator 判定通過的門檻,如果底層的評分邏輯或 threshold 設定沒有對齊,兩邊產出的 pass/fail 結果就會出現落差,讓人誤以為是平台能力的差異,實際上只是評分設定沒同步。動手前先把 evaluator 的判定邏輯(不只是 dataset 本身)也對齊成同一份定義,才能讓這份 Lab Report 的比較有意義。

Step 9:把選型變成可討論的決策表

選型會失敗,常常不是平台壞掉,而是會議一開始就只問「哪個 UI 比較順」。先把答案寫進可被反駁的表格。每一格都要附一個 owner 和證據來源;沒有 owner 的「我們會處理」通常等於尚未處理。

問題 現況證據 對 LangSmith 的影響 對 Langfuse 的影響 必須做出的決定
trace 是否含內部文件? 資料分類表、negative fixture 先確認資料平面與遮罩設定 先確認 self-host 環境和保留策略 哪些欄位可外送
團隊能否維運資料服務? on-call、backup restore 演練 managed 使用可減少平台負擔 需負責資料庫、queue、object storage 平台 owner 是誰
release 由誰放行? 現行 change policy 可接既有 evaluation workflow 需把 evaluator 與 approval 接回流程 release gate 的 owner
是否已有 Collector? OTel 架構圖、exporter 設定 仍可保留中立跨服務 trace 可將 AI trace 放入既有 OTLP 流程 primary export path
retention 多久? 法務、資安、產品要求 依契約與平台設定確認 自架後仍要設定與驗證 刪除、匯出與例外流程
兩邊 sampling 政策是否對齊? 目前各平台的 trace/error 取樣率設定 確認失敗事件是否 100% 保留 確認失敗事件是否 100% 保留 統一「失敗一律保留、成功才取樣」的規則

⑥段落補上的那個取樣數字,值得直接搬進這張表:如果兩邊沒有先對齊「失敗事件一律保留」這條規則,選型會議上比較出來的「哪個平台比較容易查到這次事故」,很可能只是兩份取樣結果不同的假象,而不是平台能力的真實差異。這格答不出來,代表整場比較的地基都還沒打穩。

這張表不能由單一工程師悄悄填完。資料治理要有資安或資料 owner;release gate 要有產品或風險 owner;平台維運要有能被叫醒的人。這比多做一張 dashboard 不討喜,但 incident 發生時會少很多猜測。

為什麼這張表要求「每一格都附證據」,而不是讓大家直接填結論? 因為選型會議最常見的失敗模式,不是討論不夠熱烈,而是每個人腦中對同一個問題的答案都不一樣,卻沒有人發現。工程師心裡想的「我們能維運」,可能只是「上次跑過一次 docker compose up」;產品經理心裡想的「retention 沒問題」,可能只是「還沒被法務問過」。把「現況證據」這一欄設為必填,逼的是每個人把腦中的假設攤開來核對,而不是讓會議停在「感覺應該可以」這種誰都無法反駁、也無法證偽的層次。這正好呼應 GitLab 事故裡那句「沒有人真正擁有這個備份流程,所以也沒有人測試過它」——沒有 owner 的假設,往往要等到事故發生的那一刻才會被戳破。

一份填完的範例(前面案例情境的政策問答服務)

空白表格不容易想像實際填起來是什麼樣子,以下用前面那個政策問答服務事故後的選型會議為例,示範表格填完後大概長怎樣:

問題 現況證據 對 LangSmith 的影響 對 Langfuse 的影響 必須做出的決定
trace 是否含內部文件? 資料分類表標示政策文件為「內部使用」等級;尚未做過 negative fixture 測試 需先確認 managed SaaS 的資料落地區域是否符合公司資料駐留政策 self-host 可將資料留在內部網路,但需自行驗證 masking 是否真的生效 資安須在下次 sprint 前補跑 negative fixture
團隊能否維運資料服務? 團隊過去半年沒有做過任何 Postgres/ClickHouse 的 backup restore 演練 可直接沿用,不需新增 on-call 項目 需要新增至少一人熟悉 ClickHouse 維運,且要排進 on-call rotation 若選 Langfuse 自架,需先培訓或外聘一名維運窗口
release 由誰放行? 現行流程是工程師自行判斷後部署,沒有正式 gate 可用既有 dataset 與 evaluator 建立最小 release gate 需額外把 evaluator 結果接回既有 CI,目前沒人排這項工作 產品與 SRE 主管共同指定 release gate owner,本季內補上
兩邊 sampling 政策是否對齊? 目前只接了 LangSmith,取樣率設為預設值(未特別調整過) 需確認預設取樣率是否已將 quality_status=failed 排除在取樣範圍外 若日後導入,需重新確認 self-host 版本的取樣預設值 工程師本季內把「失敗事件 100% 保留」明確寫進設定,不再依賴平台預設值

這份填完的表格直接反映了那次事故留下的教訓:trace 是否含內部文件 這格空白被指出「尚未驗證」,正是因為事故當下值班工程師發現,自己其實答不出「這次外洩風險有沒有被排除」;團隊能否維運資料服務 那格的答案是誠實的「沒有」,而不是含糊地說「應該可以」。把這種誠實的不確定性寫進表格,比開會時各自心裡有數但沒說出口,更能讓決策立在事實上,而不是立在假設上。

Step 10:先畫資料流,再決定資料能留在哪裡

以下資料流是建議用來討論的設計,不是宣稱已在本 Lab 實作:

Browser / API client
  │ request_id、traceparent
  ▼
FastAPI service
  ├─ structured log ───────────► Loki
  ├─ metrics ──────────────────► Prometheus
  └─ OTLP spans
       │
       ├─ local Lab ───────────► Langfuse OTLP ingestion
       │
       └─ staging / production ─► OpenTelemetry Collector
                                      ├─ trace backend
                                      └─ Langfuse exporter / OTLP route

這裡有兩個容易混淆的名詞。trace_id 是 W3C Trace Context 的技術識別;request_id 是服務自行產生、方便客服與 audit 查詢的業務識別。兩者可以互相查找,但不要把其中一個硬塞成另一個。當上游帶來合法的 traceparent,OpenTelemetry 會以它建立 child span;沒有或不合法時才建立新的 root span。這也是為什麼手動拼 trace_id 看似成功,跨服務後卻常斷掉。

metadata 也要分級。下列例子只保留可用於調查、又不需把原始內容外送的資料:

SAFE_TRACE_METADATA = {
    "request_id": request_id,
    "task_id": task_id,
    "workflow_version": "policy-qa-v2",
    "prompt_version": "2026-09-22-a",
    "retrieval_index_version": "policy-v5",
    "model_route": "primary",
    "service_version": "answer-api@2026.09.22",
}

相反地,以下值預設不應送進 trace attribute:

完整問題與對話紀錄
完整檢索文件與附件內容
Authorization header、cookie、API key、session token
email、電話、身分證字號、員工識別碼
未經分類的 tool input 或 tool output

「我只在 staging 放」不是遮罩策略。staging 常有 production-like 資料,也可能有較寬鬆的存取權。先減少資料,再談平台位置,通常比較省事。

這張資料流圖與 SAFE_TRACE_METADATA 清單,合起來驗證的是「資料治理」到底能不能落地成一份可以貼進 code review 的清單,而不是一頁只存在簡報裡的政策文件。 很多團隊的資料治理停在「我們有簽過資安規範」這個層次,實際程式碼裡卻完全沒有任何機制去執行它。這裡刻意用兩份對照的清單——SAFE_TRACE_METADATA 是「可以送出去的」,緊接著的「相反地」清單是「不應該送出去的」——用意是讓遮罩策略變成可以在 pull request 裡逐行核對的東西:新增一個 attribute,審查者只要對照這兩份清單,就能判斷這個改動有沒有踩到紅線,不需要每次都回頭重讀一份落落長的資料分類文件。

跑這一步最容易犯的錯誤,是把 staging 環境的遮罩標準放寬,理由通常是「反正只有內部在用」。三星的案例已經證明,「只有內部在用」從來不是安全的假設——外洩往往不是因為外部攻擊,而是內部人員在方便的環境裡,做了一個看似無害的動作。staging 常常跑著跟 production 相近甚至完全一樣的真實資料,卻可能有更寬鬆的存取控制,這種組合其實比 production 本身更危險。

Step 11:建立明確的 attribute allowlist

generic OTLP exporter 不知道哪個 attribute 是秘密。服務在 set_attribute() 前就要做分類。下面的範例用 allowlist 限制欄位,並刻意不把 question 或 document 放進 span:

ALLOWED_ATTRIBUTES = {
    "ai.workflow.name",
    "ai.workflow.version",
    "ai.quality_status",
    "ai.safety_status",
    "ai.task_status",
    "deployment.id",
    "model.name",
    "model.route",
    "retrieval.index_version",
    "retrieval.result_count",
    "tool.name",
    "tool.policy_decision",
}


def set_safe_attributes(span, values: dict[str, str | int | bool]) -> None:
    for key, value in values.items():
        if key in ALLOWED_ATTRIBUTES:
            span.set_attribute(key, value)

這種函式不是完整的 DLP。它至少讓「新增一個 attribute」變成可 code review 的動作。真的需要保存 prompt 或 output 時,應先確認資料分類、存取權、retention、加密與刪除流程;不要因為平台 UI 能顯示,就把它當成已核准。

若採用 Langfuse SDK,而不是 generic OTLP,mask 與 mask_otel_spans 可在送出前協助 redaction。兩者適用範圍不同:mask 處理透過 Langfuse SDK 建立的 input、output、metadata;mask_otel_spans 處理由 OTel instrumentor 匯出的原始 span attribute。兩條路徑都該有一筆「故意帶入秘密」的 negative fixture,否則遮罩只是設定檔裡一行令人安心的文字。

set_safe_attributes() 這個函式看起來很陽春,卻是本文對「masking 這件事要落到誰的責任」的具體回答。 很多團隊會把遮罩想成平台方應該提供的功能——「Langfuse 有 mask 選項,設定好就行」——但 mask 和 mask_otel_spans 都只能過濾「已經被送到它們手上」的資料,它們不會主動幫你判斷「這段文字裡有沒有身分證字號」。把守門動作往前挪到應用程式自己的 set_attribute() 呼叫之前,才能確保就算平台方的遮罩設定被改壞、或換了一個完全沒有遮罩機制的平台,敏感資料也不會外流——這正是「不要把資料治理外包給平台」的具體實作。

跑這段程式碼時最值得刻意做的一件事,是寫一個會嘗試傳入 question、api_key 之類 key 名稱的測試,確認它們真的被 allowlist 擋下。沒做過這個負向測試的 allowlist,只是一段看起來很安心、卻從沒被驗證過的程式碼——跟 GitLab 那組「以為在運作」的備份腳本,本質上是同一種風險。

這句話不該只停留在提醒層次,這裡直接把它寫成一個可以放進 CI 的最小測試,讓「allowlist 有沒有作用」這件事變成每次改動都會被自動檢查、而不是靠人偶爾想起來手動驗證:

import pytest


NEGATIVE_FIXTURE_KEYS = [
    "question",
    "document_content",
    "api_key",
    "authorization",
    "user_email",
]


@pytest.mark.parametrize("sensitive_key", NEGATIVE_FIXTURE_KEYS)
def test_sensitive_keys_are_never_set(sensitive_key):
    span = FakeSpan()  # 測試用的假 span,只記錄被呼叫過哪些 set_attribute
    set_safe_attributes(span, {sensitive_key: "this should never be recorded"})
    assert sensitive_key not in span.recorded_attributes, (
        f"{sensitive_key!r} 不在 allowlist 裡,卻被寫進了 span attribute"
    )


def test_allowlist_only_contains_known_low_cardinality_fields():
    # 防止未來有人「順手」把高基數或敏感欄位加進 allowlist 卻沒人注意到
    forbidden_substrings = ("question", "content", "email", "token", "key")
    for allowed in ALLOWED_ATTRIBUTES:
        assert not any(s in allowed.lower() for s in forbidden_substrings), (
            f"{allowed!r} 疑似不該出現在 allowlist:命名本身就暗示它可能是敏感欄位"
        )

第二個測試特別值得留意:它測的不是「今天的 allowlist 對不對」,而是「未來有人改動這份 allowlist 時,命名本身有沒有先發出警訊」。這是一種便宜卻有效的防線——沒辦法完全防止工程師把敏感欄位加進去,但至少能在 code review 或 CI 階段,逼一個看起來可疑的欄位名稱被多看一眼,而不是悄悄合併進主幹。

下集預告:下一篇(下)接著把 direct exporter 換成 Collector 隔離、把 span tree 切成能回答「技術成功 vs 語意失敗」的樹狀結構,最後填出 Lab Report 與決策 ADR,收斂出這篇系列的最終結論。


這篇是 Learning SRE for the AI Era 系列的一部分。從 Day 31 把 LangSmith 接成 trace → dataset → evaluation → release decision 的迴圈,到這篇比較 Langfuse 與 LangSmith 的治理與維運條件,核心問題始終沒變:你要用什麼證據,決定下一個版本能不能上線。

工具會換、平台會換,但這條從使用者旅程一路連回 prompt、model、retriever、evaluation 與 release decision 的證據鏈,必須自己保持完整。

Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 32 特別篇(上)|Langfuse vs. LangSmith:AI Observability 要怎麼選、怎麼整合?
下一篇
Day 32 特別篇(下)|Langfuse vs. LangSmith:AI Observability 要怎麼選、怎麼整合?
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 58 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言